Skip to content

feat: Add ServerTrace instrumentation hooks to the SSE server - #67

Open
keelerm84 wants to merge 3 commits into
mainfrom
mk/sdk-2746/server-trace-hooks
Open

feat: Add ServerTrace instrumentation hooks to the SSE server#67
keelerm84 wants to merge 3 commits into
mainfrom
mk/sdk-2746/server-trace-hooks

Conversation

@keelerm84

@keelerm84 keelerm84 commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

Adds ServerTrace, an opt-in set of callbacks that the SSE Server invokes at points in
its lifecycle, so a consumer can observe per-connection and replay behavior. This is phase 1
of the eventsource server observability work; the OTel bridge that implements these hooks
lives in ld-relay. The library creates no spans and takes no new dependencies -- it only
invokes optional callbacks. The client Stream is untouched.

The design follows net/http/httptrace.ClientTrace: a struct of optional function fields,
exposed as a new Server.Trace field. Payloads are *Info structs rather than positional
arguments so fields can be added compatibly. A nil ServerTrace, or a nil field within it,
disables the corresponding hook. The whole surface is marked EXPERIMENTAL and for use by
LaunchDarkly libraries only.

Callbacks

Callback Fires when
SubscriberAdded a subscriber is registered, before any other callback for that connection
SubscriberRemoved a connection ends -- exactly once per SubscriberAdded, with reason and duration
SubscriberDropped a subscriber falls behind BufferSize and is disconnected (dispatch goroutine, no context)
EventSent / CommentSent an individually flushed write reaches the connection (type, size, write duration; never the payload)
EventDiscarded a jitter server coalesces an event away (jitter_coalesce) or a parked event's connection ends (connection_ended)
WriteError encoding or writing to a connection fails
ReplayStarted / ReplayFinished a Repository replay batch begins / finishes -- exactly one ReplayFinished per ReplayStarted

Contract highlights

  • Reasons are right under races. SubscriberRemovedReason has six values. When a
    Server-initiated close races the handler's own exit, reasons rank deliberately:
    buffer_overflow above everything (a SubscriberDropped that fired promises a matching
    removal), write_error above the remaining Server reasons, any Server reason above the
    speculative client_closed/max_conn_time. The mechanism is a closeReason written by
    the dispatch goroutine before it closes the subscriber's channel; the channel close is the
    happens-before edge.
  • Replay reports at batch level. Replayed events are encoded in bulk and flushed once,
    so they do not emit per-event EventSent; ReplayFinished carries the count, summed
    payload bytes, and a DrainDuration that includes the end-of-batch flush.
    Aborted distinguishes an abandoned drain from a completed one. It is best-effort: a
    handler-local exit probe keeps the common case accurate (a client that takes the full
    payload and drops, racing the sentinel), while the rare Server-shutdown-drain race is
    documented rather than coordinated away -- an earlier draft made it exact with a
    cross-goroutine claim protocol, deliberately removed as not worth its reasoning burden.
  • Pairing survives panics. Callbacks must not panic, but a panicking callback cannot
    leak the subscription, strand a Repository producer, or break the 1:1 Added/Removed
    pairing (two-defer teardown). SubscriberDropped fires on the dispatch goroutine, where
    a panic would kill the process, so that one is contained and reported through the Logger.
  • Zero overhead when unobserved. All timing and accounting is gated on the consuming
    callback (or Logger) being set; an unobserved Server reads no clock and allocates nothing
    new on the write path.
  • Nothing sensitive escapes. Callbacks report payload sizes, never payloads; log lines
    identify connections by an opaque subscriber id and never include the channel name (which
    may contain credentials); encode errors render unexpected values by type only.

Logging

The same instrumentation points feed Server.Logger, covering events that were previously
silent: [DEBUG] on subscriber add/remove and replay drain, [WARN] on a slow-subscriber
drop, [ERROR] on a contained callback panic.

Review notes

Three commits: the API surface and reporting helpers, the wiring through the handler and
dispatch paths, and the tests. A companion analysis of why the contract carries teardown
machinery at all (an observability API inherits the concurrency of the thing it observes)
and what was deliberately left out is in the branch history and available on request.

@keelerm84
keelerm84 marked this pull request as ready for review August 5, 2026 15:46
@keelerm84
keelerm84 requested a review from a team as a code owner August 5, 2026 15:46
Comment thread encoder.go
return fmt.Errorf("unexpected parameter to Encode: %v", ec)
// %T, not %v: an unexpected value must not have its contents -- which
// could include an event payload -- rendered into an error string that
// flows to WriteError consumers and logs.

@kinyoklion kinyoklion Aug 6, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// flows to WriteError consumers and logs.
// logs the type (%T) and not the content, which may contain sensitive data.

ServerTrace is a struct of optional callbacks modeled on
net/http/httptrace.ClientTrace, exposed as Server.Trace: fields can be added
without breaking implementers, info structs (not positional arguments) carry
the payloads, and a nil struct or nil field disables that hook. The API is
marked EXPERIMENTAL and for LaunchDarkly libraries only.

This commit adds the contract and the Server-side reporting helpers; the next
commit wires them through the handler and dispatch paths.

Design points:

- Callbacks fire synchronously on internal goroutines and must return
  promptly and never panic; SubscriberDropped fires on the dispatch
  goroutine, where an unrecovered panic would take down the process rather
  than one connection, so that helper contains panics and reports them
  through the Logger (rendering only the panic value's type, never its
  contents).
- Handler-goroutine callbacks receive the subscriber's request context for
  telemetry correlation only.
- The same instrumentation points feed Logger: DEBUG on subscriber
  add/remove and replay drain, WARN on a slow-subscriber drop. Log lines
  identify connections by an opaque per-Server subscriber id and never
  include the channel name, because channel names may contain credentials.
- Measurement is gated on the consuming callback being set (shouldMeasureWrite,
  writeTraced, beginSubscription/sinceOrZero), so a Server with neither Trace
  nor Logger reads no clock and allocates nothing new on the write path.
- Encode errors render unexpected values with %T, not %v, so payload
  contents cannot leak into error strings that reach WriteError consumers
  and logs.
Calls the hooks at every lifecycle point and makes what they report true
under the teardown races the server already had:

- Exit reasons: each read-loop exit records its reason; for Server-initiated
  closes, run() records closeReason before closing the subscriber's channel
  and the handler reads it only after observing that close (the channel close
  is the happens-before edge). When a Server close races the handler's own
  exit, reasons rank: buffer_overflow above everything (SubscriberDropped
  already promised a matching removal), write_error above the remaining
  Server reasons, any Server reason above the speculative client_closed and
  max_conn_time.
- Teardown: the handler tears down in two defers. reportExit runs first and
  holds the consumer-reachable reporting; cleanup registers earlier so it
  still resolves the reason, unsubscribes, and emits SubscriberRemoved while
  a panic from a reporting callback is unwinding. reportedAdded is set before
  SubscriberAdded so even a panic inside that callback produces the balancing
  removal. SubscriberAdded fires before the subscription is registered, so no
  other callback can precede it.
- Replay: batches report at batch level (count, summed payload bytes,
  DrainDuration through the end-of-batch flush, measured only when consumed).
  Every ReplayStarted gets exactly one ReplayFinished; a batch abandoned
  mid-drain reports Aborted, with a handler-local exit probe that keeps the
  common disconnect-races-sentinel case reported as completed. Aborted is
  documented best-effort: a Server shutdown draining an abandoned batch
  concurrently can make it read as completed. Concurrent drains themselves
  are safe -- receivers split discarded events until the channel closes.
- Accounting: a jitter-parked event whose connection ends is reported
  discarded (connection_ended), so every event is accounted sent or
  discarded; the parking slot clears before the write so a panicking
  EventSent cannot double-report it.
- Drops: a replay-batch enqueue that overflows the buffer reports
  SubscriberDropped exactly as trySend does; a handler racing Server.Close
  escapes the registration send instead of parking forever.
Covers every callback, all six removal reasons and their precedence under
races, context propagation, replay batch accounting (count, bytes, durations,
best-effort Aborted), the panic-teardown invariants (a panicking callback
never leaks the subscription, strands a producer, or breaks the 1:1
Added/Removed pairing), zero-overhead gating, and a concurrency stress test
of the pairing invariant. Includes the encoder %T error-format pin.
@keelerm84
keelerm84 force-pushed the mk/sdk-2746/server-trace-hooks branch from 045766e to 1b907e8 Compare August 6, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants